Skip to content

feat: add webjs ci, a local CI runner the scaffold workflow reuses - #1472

Merged
vivek7405 merged 10 commits into
mainfrom
feat/local-ci
Sep 10, 2026
Merged

feat: add webjs ci, a local CI runner the scaffold workflow reuses#1472
vivek7405 merged 10 commits into
mainfrom
feat/local-ci

Conversation

@vivek7405

@vivek7405 vivek7405 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #1471

webjs ci runs an app's declared step list locally with the Rails 8.1 bin/ci experience: a timed result line per step, a failure list and one total line, --fail-fast, --only <title>, --json for an agent loop, and an opt-in --signoff that posts a green commit status through gh signoff. The list lives in package.json under webjs.ci (a string, a { title, run, env } command, or a { title, steps, parallel } group whose steps run N at a time with each step's output replayed whole). Every scaffolded app ships a default list plus a ci script, the scaffold's GitHub workflow collapses to one job that runs the same list through npm run ci so the two cannot drift, webjs create --skip-ci omits that workflow, and the monorepo root and its three in-repo apps declare their own lists.

Research record: #1470.

What changed

  • Config key webjs.ci in the schema (draft-07 definitions for the recursive step shape, a nested group cannot declare parallel), the WebjsConfig type, KNOWN_KEYS plus a nested guard, and the type fixture. The pure reader packages/cli/lib/ci-config.js normalizes the shorthand and reports every malformed entry by JSON path, because the boot validator never follows a $ref and a silently dropped step is a check that never ran.
  • Runner packages/cli/lib/ci-runner.js. Sequential steps inherit stdio and are not detached (Ctrl-C reaches them natively). Captured steps are detached with stdin ignored, buffered in arrival order, resolved on close with a bounded grace after exit. FORCE_COLOR reaches captured children only when the parent stdout is a TTY. Under GitHub Actions each step is a ::group::, a failure an ::error:: annotation, and a step table lands in $GITHUB_STEP_SUMMARY, so one job still names the layer that broke. Pure of process.exit, console, and the clock.
  • Command webjs ci. The predicate is the config, not an app/ directory (unlike webjs check), so a workspace root is a legitimate target. Nothing declared is exit 1 naming the members that declare one; a malformed block is exit 1 with every problem; --json puts one document on stdout and the human report on stderr; the exit code goes through exitCode so a non-TTY stdout is never truncated.
  • Scaffold. Default list (Setup, then a Checks group two at a time: check, doctor, typecheck, npm audit --audit-level=high or bun audit on Bun, and a sequential Tests sub-group of server, browser, e2e), a ci script, a one-job workflow with a read-only token, a timeout, and the concurrency cancel, --skip-ci, and the agent rule files, playbooks, PR template, and pre-commit comment now name npm run ci as the pre-push gate. The pre-commit hook itself is unchanged (Lighten pre-commit to convention-check only; move test gate to CI #174).
  • Monorepo. The root declares a list mirroring the GitHub jobs behind npm run ci (the jobs themselves stay the required checks), gallery / examples/blog / website each declare a shorter one, and test/repo-health/in-repo-ci-blocks.test.mjs pins all four.
  • Docs on every surface the doc-sync map names (see the table below).

Deliberately excluded

  • A doctor advisory for a missing webjs.ci, gh signoff fail posting, MCP exposure.
  • Converting the framework's own GitHub jobs to npm run ci (they are the required merge checks).
  • A PTY for captured children (a native dependency, which a buildless framework will not take on); node --test under a pipe reports as TAP, documented.

Test plan

  • Unit: packages/cli/test/ci-config (7), packages/cli/test/ci-runner (12, scripted fake child, fake clock, fake timers, pool cap, replay atomicity, exit-then-data, grace, interrupt, Actions mode), packages/server/test/config (schema lockstep + nested guard), test/types/webjs-config.test-d.ts with three @ts-expect-error counterfactuals (type-checked against the branch copy of core via a paths map; the in-tree run resolves the primary checkout through the linked node_modules and is the known worktree artifact)
  • CLI end to end: test/cli/ci.test.mjs (9: refusals, green, red, fail-fast, --json, --only, Actions groups + step summary, help)
  • Bun parity: test/bun/ci-runner.mjs on Node and on Bun 1.3.14 (real children, captured stderr, a detached sleep reaped on interrupt); the Bun matrix filtered to ci-runner and webjs-config reports no genuine failures
  • Drift guards: the prose hook's CLI list, help, check-target, published-package descriptions (96 pass)
  • Scaffold: test/scaffolds/* (61 + the --skip-ci counterfactual for the library option and the CLI flag), runtime-rewrite (npm run ci becomes bun run ci, npm ci still becomes bun install); a full-stack app generated WITH install runs npm run ci end to end green (Setup, Conventions, Health, Types, audit, server, browser, e2e) once Playwright Chromium is present, which is the workflow's own install step
  • Monorepo dogfood: root npm run ci -- --only Conventions green (8 steps, three at a time, 2.8s); gallery, examples/blog, website each green on their own list through the branch CLI (the website run after the docs edits, which covers the docs-links and llms guards)
  • Manual TTY: the Checks group under a pseudo-terminal renders and clears the progress line, colours the captured tools, and replays each step whole
  • npm test, full Node suite. Eight failures, all accounted for: the five that always fail in a linked worktree (listener, listener-overhead, three elision assertions), the config type fixture (bare specifier resolving into the primary, verified separately as above), and two #1451 no-any guards that parse tsc's plain output while this shell exports FORCE_COLOR=3; that file passes 4 of 4 with the variable unset

Two things a reviewer will notice in a generated app before the next release: the published @webjsdev/server schema does not know the ci key yet, so a scaffolded app booted against the published packages prints one boot warning (unknown key "ci") that disappears on release, and npm run ci in that app must be run through this branch's CLI until the CLI ships.

Definition of done

Surface Status
Tests Updated, every layer above
AGENTS.md Updated (CLI reference, create --skip-ci, the webjs block bullet, code-workflow items 1 and 4)
.agents/skills/webjs/ Updated (references/built-ins.md Local CI section, references/testing.md one-command section, SKILL.md Testing Defaults)
packages/cli/AGENTS.md, packages/server/AGENTS.md Updated (file inventory + command row; reader inventory)
website/app/docs/ Updated (configuration CLI Options entry + key count, testing section, deployment checklist)
README.md, packages/cli/README.md Updated
framework-dev.md Updated (the monorepo's own local CI)
Scaffold templates Updated (workflow, pre-commit comment, workflow.md, both playbooks, PR template) plus create.js and the CLI help
Marketing website/ N/A because no landing claim changes; the docs pages carry it
Changelog Automatic from the feat: title
examples/blog/CONVENTIONS.md N/A because it does not describe the test gate

Review round

The review on this PR posted eight findings; all eight are fixed in d4e2780 with a counterfactual each (the grace-path reap proven on Node and Bun with a leaked background sleep, the nested-parallel rule aligned across the reader, schema, type, and docs with steps required on all three, nested-group progress bookkeeping, a second Ctrl-C that exits, the interrupted total line, a refused bare --only, NO_COLOR honoured on a TTY, the docs key count), answered in their threads, and the threads resolved so the required conversation-resolution gate is satisfied; reopen any that needs more.

Two follow-on commits while making the PR mergeable: 879e140 points scripts/protect-main.sh at the renamed In-repo app tests (website + blog + gallery) job (the required check had kept the old name since #1371, so it could never report) and the corrected protection is applied; 0db2086 forwards --skip-ci through the create-webjs wrapper so npm create webjs and bun create webjs match webjs create.

Monorepo local CI, measured on a 24-core machine

Sequential root list: 9m22s. Regrouped so the big steps share one Gate group: six slots finished in 4m42s but overloaded the box (Firefox could not launch a web-test-runner page inside its 30s start timeout); three slots finished in 4m35s with the browser suite green in 53s, so three is what ships. The wall-clock is now the blog e2e alone (4m30s). Per step this machine beats the Actions runner two to three times (unit 42s vs 2m23s, browser 53s vs 2m41s, app suites 28s vs 1m15s); the only reds in a linked worktree are the known listener and type-fixture artifacts that pass in CI.

Local CI (#1471) needs a step list an app declares once and every tool
can read without importing app code, so it lives in the package.json
`webjs` block next to the #550 dev/start orchestration. This lands the
key in the three-surface lockstep (the JSON Schema with recursive
definitions for a step, the WebjsConfig type, KNOWN_KEYS plus a nested
guard for the recursive shape) and the pure CLI reader that normalizes
the shorthand and reports every malformed entry with its JSON path.

The reader validates the shapes itself because the boot validator only
checks top-level key membership and never follows a $ref, and a step
that is silently dropped is a check that never ran, the exact false
green local CI exists to prevent. A group nested inside a parallel
group takes one slot and runs sequentially, so a `parallel` on it is
reported rather than honoured, the same rule Rails' runner applies.

The runner and the `ci` command follow in the next commits.
@vivek7405 vivek7405 self-assigned this Sep 10, 2026
The runner behind the ci command (#1471), modeled on Rails 8.1's
ActiveSupport::ContinuousIntegration: a heading and a timed result line
per step, a failure list plus one total line, fail-fast, and parallel
groups whose steps run on N slots with output captured and replayed
whole so nothing interleaves, a nested group taking one slot.

Two spawn shapes on purpose. A sequential step inherits stdio and is
not detached, so it owns the terminal and Ctrl-C reaches it natively,
the split webjs dev already makes for before-steps versus watchers. A
captured step is detached (its own process group, reaped on
interrupt), has stdin ignored so a TTY-reading tool cannot stop on
SIGTTIN and hang the pool, and resolves on close rather than exit,
with a bounded grace so a leaked grandchild holding the pipe cannot
hang the run. FORCE_COLOR reaches captured children only when the
parent's stdout is a TTY; Node has no PTY without a native dependency.

Under GitHub Actions each step is folded into a log group and a failure
is annotated, so one cloud job running the whole list still names the
layer that broke, which is what the per-layer jobs used to buy.

Pure of process.exit, console, and the clock, so the slot cap, the
fail-fast cutoff, replay atomicity, exit-then-data ordering, and the
grace path are all proven with a scripted fake child.
The local CI command (#1471): run the step list package.json declares
under webjs.ci, with -f/--fail-fast, --only <title>, --json, and an
opt-in --signoff that posts a green commit status through gh signoff
after a green run, the Rails 8.1 bin/ci posture.

The predicate is the config, not an app/ directory, unlike webjs check,
because a workspace root is a legitimate target and this monorepo
declares its own list. Nothing declared is exit 1 rather than 0, since
a run of zero steps would read as green; the refusal names the
workspace members that do declare one. A malformed block refuses with
every problem's JSON path and runs nothing.

Under --json stdout carries exactly one document and the human report
moves to stderr; the exit code is set through exitCode so a non-TTY
stdout is never truncated. .env is loaded before the steps, like dev
and start, so a local db migrate step sees DATABASE_URL while a CI
runner's explicit env still wins.

The prose hook's CLI subcommand list gains ci so its drift test keeps
passing, and the end-to-end CLI test and the Node + Bun proof script
cover the exit codes, the output shape, the env every child sees,
fail-fast, --only, the refusals, the GitHub Actions log groups and
step summary, and a real interrupt reaping a detached sleep.
Every new app now declares its CI once, in package.json under webjs.ci
(#1471): a Setup step, then a Checks group running two at a time
(webjs check, webjs doctor, webjs typecheck, a dependency audit) with a
sequential Tests sub-group (the server, browser, and e2e layers), plus
a `ci` script. The Tests group stays sequential because the server and
e2e layers share one SQLite file. Every step is a bare `webjs ...`
command, the same bar the before-steps meet, and a Bun app audits with
bun audit.

The generated GitHub workflow collapses from four hand-restated jobs to
one job that prepares the runner and runs `npm run ci`, so the cloud
and local lists cannot drift, which is the Rails guide's rule for every
provider. The cloud practices stay: a read-only token, a bounded job, a
concurrency cancel, and, through the runner's Actions mode, a log group
per step, an annotation per failure, and a step table in the job
summary, so a failure still names its layer. A team wanting per-layer
required checks runs `webjs ci --only` in a matrix, as the workflow
comment says.

`webjs create --skip-ci` omits the workflow and nothing else (rails new
parity); the local list always ships. The pre-commit hook is unchanged
per #174: `npm run ci` is the pre-push gate, and the scaffold's agent
rule files, playbooks, and PR template now say so.
The framework dogfoods its own local CI (#1471). The root package.json
declares a webjs.ci list mirroring the GitHub jobs (setup, the
per-app check and doctor plus the two source invariants three at a
time, the root test suite, the in-repo app typechecks and suites, the
browser suite, the blog e2e, the Bun matrix) behind `npm run ci`, and
gallery, examples/blog, and website each declare a shorter list behind
their own `ci` script.

The root script and every root step invoke this checkout's CLI by path
rather than a hoisted bin, because in a linked worktree
node_modules/.bin resolves into the primary checkout. The app lists go
through their npm scripts because the website's pretest hook copies the
ui registry, which a bare test invocation would skip. A repo-health
test parses all four blocks through the same reader the command uses
and checks that every npm script a step names exists in the package it
targets, so a stale step fails here rather than at run time with a
message that never mentions the block.

The GitHub workflow itself is unchanged: its jobs are the required
merge checks.
The ci command and the webjs.ci config key (#1471) on each surface the
doc-sync map names: the AGENTS.md CLI reference, config-block bullet,
and code-workflow items; the agent skill (a Local CI section in
built-ins.md, a one-command section in testing.md, a Testing Defaults
bullet in SKILL.md); the docs site (a webjs ci entry under CLI Options
on the configuration page with the step shapes, the flags, the Actions
surfaces, and the signoff merge gate, a webjs ci command section on the
testing page, a deployment-checklist item, and the known-key count);
the root and CLI READMEs; and framework-dev.md for the monorepo's own
list.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Decisions carried from the plan, and two that moved during the build

Settled in #1470 and kept: JSON in the webjs block rather than a ci.ts DSL (the #550 precedent, and no tool should load app code to learn the step list), one cloud job consuming the same list (the Rails guide's rule for every provider), the pre-commit hook untouched (#174), signoff opt-in.

Two calls moved while building.

webjs ci does not refuse at a workspace root the way webjs check does. The plan reused findCheckTarget; the monorepo running its own local CI made the app/ predicate wrong, so the predicate is the webjs.ci block itself, and a missing block is the refusal, naming the workspace members that declare one. The root list mirrors the GitHub jobs and invokes this checkout's CLI by path, because a linked worktree's hoisted bin resolves into the primary.

The per-layer status checks the four-job workflow used to provide are replaced, not dropped. The runner detects GITHUB_ACTIONS and folds each step into a log group, annotates a failure with ::error title=<step>::, and appends a step table to the job summary, so the single job still names the layer that broke. The workflow also gains permissions: contents: read and timeout-minutes, which the old one lacked.

Two environmental findings worth knowing when reading a local run. A shell exporting FORCE_COLOR (this one had 3) makes child tools print a NO_COLOR is ignored warning and switches tsc to its pretty output, which is why two #1451 guards red under npm test here and pass with the variable unset; the runner itself only sets FORCE_COLOR for a captured child when the parent stdout is a TTY. And the browser layer needs a Playwright Chromium on the machine, which the workflow installs for itself; the scaffold docs say so.

@vivek7405
vivek7405 marked this pull request as ready for review September 10, 2026 13:06

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the whole diff (runner, reader, bin, schema/type, scaffold, workflow, tests, docs). The design holds together well: the reader refusing on any shape problem, the inherit-vs-capture split, the resolve-on-close rule, and the scaffold workflow collapsing onto npm run ci are all the right calls, and the tests pin the parts that matter.

Eight findings, two of which I would fix before merging:

  1. A truncated captured step hangs the process (ci-runner.js, the grace timer). The result resolves, the summary prints, and then webjs ci sits there, because the leaked grandchild still holds the pipe and the bin exits through exitCode. I probed it with sleep 6 & echo hi; exit 0 under closeGraceMs: 300: the result came back at 310ms flagged truncated, the process exited at 6.0s. A leaked dev server never releases the pipe, so that run never exits.
  2. Three surfaces disagree about a nested parallel. The reader honours parallel on a group nested inside a SEQUENTIAL group (and accepts parallel: 1 anywhere), while the schema, the WebjsCiNestedGroup type, the @ts-expect-error fixture and the docs all say a nested group can never declare it. An editor red-flags a config the CLI runs. Details inline.

The rest: the nested Tests group inside the pool bypasses the progress line bookkeeping (the scaffold default hits it on every TTY run), a second Ctrl-C is swallowed, the total line can never read interrupted, --only with no value silently runs everything, NO_COLOR is not honoured on a TTY, and one stale count on the configuration docs page.

Not flagged but worth knowing: npm audit needs a lockfile and the network, so a --no-install app or an offline machine reds the default list at the audit step. That matches bundle audit in Rails' list, so I left it.

Comment thread packages/cli/lib/ci-runner.js Outdated
Comment thread packages/cli/lib/ci-config.js Outdated
Comment thread packages/cli/lib/ci-runner.js Outdated
Comment thread packages/cli/bin/webjs.js Outdated
Comment thread packages/cli/lib/ci-runner.js Outdated
Comment thread packages/cli/bin/webjs.js Outdated
Comment thread packages/cli/bin/webjs.js
Comment thread website/app/docs/configuration/page.ts Outdated
Eight findings from the review on #1472, each with its counterfactual.

A captured step whose grandchild kept the pipe open resolved as
truncated but left the group alive and the pipe handles open, so the
bin printed its summary and then sat until the grandchild died. The
grace bound now reaps the group (the child is detached, so it is still
addressable) and destroys both pipes; the Node and Bun proof runs a
`sleep & echo; exit 0` step and asserts the run settles through the
grace and pgrep finds nothing.

The reader honoured `parallel` on a group nested inside a sequential
group while the schema, the type, and the docs all said a nested group
never declares it. One rule now: a nested group refuses `parallel`
whatever its parent, and `steps` is required on the schema, the type,
and the reader alike (a block with no steps is refused, since zero
steps would read as green).

A nested group inside a pool bypassed the progress bookkeeping, so on a
TTY its replays did not clear the line and no line showed while it
ran, which is the scaffold's default shape (Tests inside Checks). Every
step in a slot now shares one path.

A second Ctrl-C exits with 130 outright, the way Rails' bin/ci lets a
repeated interrupt through, so a captured child that ignores SIGTERM
cannot trap the user. The total line reads "interrupted" after an
interrupt instead of "failed" (the old expression was dead). A bare
`--only`, or one followed by a flag, is refused instead of running the
whole list. Colour follows NO_COLOR on a TTY, for the runner's own
lines and for the FORCE_COLOR handed to captured children. The
configuration page's free-form key list names `ci` and counts ten.
The apps job became "In-repo app tests (website + blog + gallery)" in
#1371, but scripts/protect-main.sh, and the protection it had applied,
still required the old name, so that required check could never
report and every PR since has shown as blocked on a check that no
longer exists. Name the real job so a green run satisfies the gate.
`npm create webjs` and `bun create webjs` run this wrapper, so the
flag `webjs create` gained in #1471 has to reach scaffoldApp from here
too, or `npm create webjs my-app -- --skip-ci` would silently scaffold
the workflow, the same gap --db once had. The usage and README name it,
and a source-level test pins the forwarding, because the wrapper
resolves the CLI by bare specifier and a linked worktree would run the
primary checkout's copy.
The root webjs.ci list ran its big steps one after another, so a full
run took 9m22s on a 24-core machine although no single step needed
more than 4m30s. After Setup, everything now sits in one Gate group
with three slots, longest first (the blog e2e, the Bun matrix, the
browser suite, then the root test suite, the in-repo app suites, and
the Conventions checks as slots free up), so the wall-clock is the
longest step rather than the sum. Each slot's output is captured and
replayed whole, so nothing interleaves.

Three rather than six: six overloaded the box, and web-test-runner's
Firefox could not launch a test page inside its 30s start timeout.
With the e2e alone as the floor, three slots cost no wall-clock. The
Conventions group loses its own three-way parallelism, which cost
nothing measurable (eight sub-second steps). The repo-health guard
still parses the block clean, and framework-dev.md describes the shape.
@vivek7405
vivek7405 merged commit 1b80e0c into main Sep 10, 2026
10 checks passed
@vivek7405
vivek7405 deleted the feat/local-ci branch September 10, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add webjs ci, a local CI runner the scaffold workflow reuses

1 participant